Skip to content

ci: add ruff lint and bandit SAST, and SHA-pin all actions - #41

Merged
bandrel merged 1 commit into
mainfrom
ci/lint-and-sast
Aug 21, 2026
Merged

bandrel merged 1 commit into
mainfrom
ci/lint-and-sast

Conversation

@bandrel

@bandrel bandrel commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Adopts the three patterns worth taking from hate_crack's CI, scaled to a single-module stdlib project. Skipped from that repo: pip-audit (spoonmap has dev-only deps, so it would audit pytest and little else, and Dependabot already covers it), ty type checking, and all release/tag/PyPI automation (no releases here).

ruff

New lint job: uv run --frozen ruff check spoonmap.py tests/.

The ruleset is deliberately narrow — E4, E7, E9, F — and ruff is exact-pinned (ruff==0.16.4) in the dev group, so a ruff release that grows a new opinion cannot fail an unrelated PR. ruff format is not adopted: reformatting a 4.6k-line module and an 11k-line test file would bury every future diff under churn, and E501 alone would flag 288 existing lines.

The first run found 17 real issues. All are fixed here, none suppressed:

Where Finding
spoonmap.py ambiguous l loop variable (E741); 2x f-string with no placeholders (F541)
tests/ 5x unused import (F401); 4x multiple imports on one line (E401); lambda bound to a name (E731); ambiguous l (E741); 3x dead local (F841)

Three of the dead locals deserved a look rather than a blind --fix, and one is a genuine (if harmless) test smell: TestMassScanUdp's two adjacent tests each bound both mock_m and mock_u while asserting on only one. The unused binding is dropped from each — the patch() stays, only the as name goes — so neither test loses a patch. The third discarded a mass_scan() return value the test never read.

bandit

New bandit job, SAST against a committed .bandit-baseline.json.

A bare run reports 32 findings on spoonmap.py, none of them High: 12x B603 (list-form subprocess), 12x B314 (xml.etree on masscan/nmap output we invoked ourselves), plus B110/B112/B404/B405/B607. These are inherent to a scanner wrapper and already reviewed — CLAUDE.md documents the list-form-subprocess and defensive-XML-parsing invariants. Baselining them is what makes this a gate rather than a wall of noise, and it keeps the justification in git history instead of scattering # nosec comments through the module.

I verified the gate works in both directions, since a SAST job that can only ever pass is worse than none:

  • clean against the baseline → exit 0, zero issues
  • temporarily appended def _bandit_probe(x): return eval(x) → exit 1, B307 reported
  • shifted every line number in spoonmap.py by 2 → still exit 0, so the baseline is not line-fragile and unrelated edits won't produce phantom findings

Note xml.etree (B314) is baselined rather than fixed: switching to defusedxml would add a runtime dependency to a deliberately stdlib-only tool, and the XML being parsed is output from tools this process just spawned.

Supply-chain hygiene

Actions are pinned to commit SHAs with the tag in a trailing comment, replacing mutable @v4/@v6 tags — a tag is rewritable, so @v4 is effectively an unaudited third party with access to the build. Every checkout also gets persist-credentials: false.

I verified the SHAs against the upstream tag refs rather than copying hate_crack's values on faith:

  • actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 = v7.0.1
  • astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d = v10.0.1

Being current on both also clears the Node 20 deprecation warning the old tags were emitting.

Verification

  • uv run --frozen ruff check spoonmap.py tests/ → All checks passed
  • bandit -r spoonmap.py -c pyproject.toml -b .bandit-baseline.json → exit 0
  • actionlint .github/workflows/ci.yml → 0 errors
  • uv lock --check → in sync
  • uv run --frozen --python 3.12 pytest tests/ -q → 971 passed, 5 skipped, 100% coverage
  • uv run --isolated --no-project --python 3.8 ... pytest tests/ -q → 971 passed, 5 skipped, 99.91%

Docs updated in README (### Continuous integration, including the re-baseline command) and CLAUDE.md.

Adopts the three patterns worth taking from trustedsec/hate_crack's CI.

ruff: a new `lint` job runs `ruff check spoonmap.py tests/` against a
deliberately narrow ruleset (E4, E7, E9, F) with ruff exact-pinned in the dev
group, so a ruff release that grows an opinion cannot fail an unrelated PR.
`ruff format` is not adopted: reformatting a 4.6k-line module and an 11k-line
test file would bury every future diff, and E501 alone flags 288 existing
lines.

Its first run found 17 real issues, all fixed here rather than suppressed:

- spoonmap.py: an ambiguous `l` loop variable and two f-strings with no
  placeholders
- tests: 5 unused imports, 4 multiple-imports-on-one-line, a lambda bound to
  a name, another ambiguous `l`, and 3 dead locals

Three of those dead locals were worth a second look rather than a blind
--fix. Two adjacent tests each bound both `mock_m` and `mock_u` but asserted
on only one, so the unused binding is dropped from each while the patch stays
in place; the third discarded a mass_scan() return value the test never read.

bandit: a new `bandit` job runs SAST against a committed baseline. A bare run
reports 32 findings on spoonmap.py (12x B603 list-form subprocess, 12x B314
xml.etree on output from tools we invoked ourselves, plus B110/B112/B404/
B405/B607) — all reviewed and inherent to a scanner wrapper. Baselining them
means only a NEW finding fails the build, which is the difference between a
gate and a wall of noise. Verified both directions: clean against the
baseline, and exit 1 with a fresh B307 when an eval() was temporarily added.
Also verified the baseline is not line-number fragile, so unrelated edits
that shift lines do not produce phantom findings.

Actions are now pinned to commit SHAs with the tag in a trailing comment
instead of mutable `@v4`/`@v6` tags, and every checkout sets
`persist-credentials: false`. The pinned versions (checkout v7.0.1, setup-uv
v10.0.1) were verified against the upstream tag refs, and being current also
clears the Node 20 deprecation warning the old tags emitted.

Verified locally: ruff clean; bandit clean against baseline; actionlint clean;
uv lock --check clean; 971 passed / 5 skipped at 100% coverage on the locked
3.12 path and 99.91% on the unlocked 3.8 path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bandrel
bandrel merged commit 2275fd8 into main Aug 21, 2026
9 checks passed
@bandrel
bandrel deleted the ci/lint-and-sast branch August 21, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant